package com.cwl.client;

import com.cwl.properties.FileProperties;
import lombok.AllArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.io.FileUtils;
import org.apache.http.HttpEntity;
import org.apache.http.HttpResponse;
import org.apache.http.client.HttpClient;
import org.apache.http.client.methods.HttpGet;
import org.apache.http.impl.client.HttpClients;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import java.io.*;
import java.net.URLDecoder;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.TimeUnit;

@RestController
@Slf4j
public class DownloadClient {
    FileProperties fileProperties;
    ExecutorService coreThreadPool;//使用线程池进行下载

    //构造器注入
    public DownloadClient(FileProperties fileProperties, ExecutorService coreThreadPool) {
        this.fileProperties = fileProperties;
        this.coreThreadPool = coreThreadPool;
    }

    /**
     * 1.获取文件信息：文件大小、文件名
     * 2.多线程分片下载
     * 3.最后一个文件下载完后合并
     */
    @RequestMapping("/downloadFile")
    public String downloadFile() {
        //TODO 如果追求性能，可以把探测的字节也写到方法里面
        //这一次为探测，并没有下载功能，只是单纯探测文件情况获取文件信息，可以理解为远程调用文件接口获取服务端文件信息
        FileInfo fileInfo = download(0, 0, -1, null);
        if (fileInfo != null) {
            long pages = fileInfo.length / fileProperties.getPerPage();//获取分片数量，判断需要用多少个线程
            for (long i = 0; i <= pages; i++) {
                coreThreadPool.submit(new Download(i * fileProperties.getPerPage(), (i + 1) * fileProperties.getPerPage() - 1, i, fileInfo.fileName));
//                download(i * fileProperties.getPerPage(),(i+1) * fileProperties.getPerPage() - 1,i,fileInfo.fileName);
            }
        }
        return "success";
    }

    /**
     * 断点下载获取分片信息
     *
     * @param start    开始位置
     * @param end      结束位置
     * @param page     第几个分片
     * @param fileName 分片文件名
     * @return 文件信息
     */
    private FileInfo download(long start, long end, long page, String fileName) {
        File file = new File(fileProperties.getDownPath(), page + "-" + fileName);

        /*
         * 【断点下载关键】
         * 当断点下载时，文件会出现已经存在的情况，当文件存在时要判断以下情况
         * 1.page = -1时，这个文件为探测文件：探测文件只是用于返回FileInfo也就是文件长度，当
         * 2.文件长度要符合分片长度标准，因为有的分片由于网络中断可能出现损坏或传到一半的情况，这时需要重新下载
         * 3.当file.exists()==true且这个分片已经符合分片长度标准，说明这个文件已经存在，上次已经下载成功了，就可以跳过，无需继续下载了
         */
        if (file.exists() && page != -1 && file.length() == fileProperties.getPerPage()) {
            return null;
        }
        String fSize = null;
        try (BufferedOutputStream bos = new BufferedOutputStream(new FileOutputStream(file))) {
            HttpClient client = HttpClients.createDefault();
            HttpGet httpGet = new HttpGet("http://127.0.0.1:8080/download");//远程请求
            httpGet.setHeader("Range", "bytes=" + start + "-" + end);//告诉服务端我要分片,从何处开始何处结束
            HttpResponse response = client.execute(httpGet);
            fSize = response.getFirstHeader("fSize").getValue();//拿到文件头中的文件长度
            fileName = URLDecoder.decode(response.getFirstHeader("fName").getValue(), "utf-8");//拿到文件头中的文件名，解码，否则会出现乱码
            HttpEntity entity = response.getEntity();//拿到相应主体
            try (BufferedInputStream bis = new BufferedInputStream(entity.getContent())) {//拿到文件流
                byte[] buffer = new byte[1024];
                int len;
                while (-1 != (len = bis.read(buffer))) {
                    bos.write(buffer, 0, len);
                }
                bos.flush();
                log.debug("【客户端】分片{}下载成功！", page);
            }
        } catch (IOException e) {
            e.printStackTrace();
        }
        if (fSize != null) {
            //最后一个分片,执行合并，这里一定要注意执行顺序的问题，合并方法不能放在try块中，否则文件会因为流还没有关闭而导致无法删除
            if (end - Long.parseLong(fSize) > 0) {
                mergeFile(fileName, page);
            }
            return new FileInfo(Long.parseLong(fSize), fileName);
        } else {
            return null;
        }
    }

    /**
     * 文件合并，当下载的刚好是最后一个分片时触发，但是这里要注意并发问题
     * 1.由于并发，当下载完最后一个分片时其他分片可能还没有下载完
     * <p>
     * 2.在合并时如果被中断了怎么办
     * 2-1.文件在合并时由于是顺序合并，并且合并好一个删除一个，所以当合并到一半被中断会重新合并
     * 2-2.至于被删除的分片，重新下载时会认为该分片不存在而重新下载一次
     * 2-3.由于重新合并和OutputStream的重写构造器可以选择不追加选择重新覆盖写入的方式，所以可以保证重新写的文件完整性
     * <p>
     * 3.如果有字节出现损坏缺失怎么办
     * 当文件不存在或者该分片不是最后一个分片但又没有达到标准分片长度时，自旋等待（等待其下载完成）
     * 但是由于各种未知问题，可能出现分片就是有字节损失，那么这个时候就不等了，需要有一个结束的临界值判断
     * 当出现未知问题时，需要用一些策略来判断要如何处理
     * 3-1.如果有字节出现损坏缺失，那么等待一定时间后重新下载
     * 3-2.如果有字节出现损坏缺失，那么等待一定时间后就不合并了直接结束，因为合并的文件很可能用不了，可以选择抛出异常或什么也不做
     * 3-3.即便有字节出现损坏缺失，也进行合并，不关心结果，只要程序能保证可用即可
     *
     * @param fileName 下载文件名
     * @param page     当前分片数，前面已经控制最后一个分片才会进来，所以可以解为最后一个分片的下标
     */
    private void mergeFile(String fileName, long page) {
        File file = new File(fileProperties.getDownPath(), fileName);
        try (BufferedOutputStream bos = new BufferedOutputStream(new FileOutputStream(file))) {
            log.info("【客户端】开始文件合并");
            for (int i = 0; i <= page; i++) {
                log.debug("【客户端】分片{}开始合并", i);
                long startTime = System.currentTimeMillis();
                File splitFile = new File(fileProperties.getDownPath(), i + "-" + fileName);
                /*
                 *并发问题
                 * 1.当最后一个分片先下载完而前面的分片还没下载完时不能合并
                 * 2.当前面文件不存在时不能合并，要先等待
                 * 3.当前面的文件没下载完时，字节数不够，要等待
                 * 4.最后一个分片不需要考虑第二、三种情况，因为顺序执行，到这里时最后一片一定已经下载完了
                 */
                while (!splitFile.exists() || (i != page && splitFile.length() < fileProperties.getPerPage())) {
                    TimeUnit.MILLISECONDS.sleep(100);
                }
                byte[] bytes = FileUtils.readFileToByteArray(splitFile);
                bos.write(bytes);
                bos.flush();
                long endTime = System.currentTimeMillis();
                long consumeTime = endTime - startTime;
                log.debug("【客户端】分片{}合并成功，耗时{}毫秒", i, consumeTime);
                boolean delete = splitFile.delete();
                log.debug("【客户端】分片{}删除{}", i, delete ? "成功" : "失败");
            }
            log.info("【客户端】合并完成");
            //探测文件也要删掉
            File temp = new File(fileProperties.getDownPath(), -1 + "-null");
            boolean delete = temp.delete();
            log.debug("临时文件删除{}", delete ? "成功" : "失败");
            bos.flush();
        } catch (IOException | InterruptedException e) {
            e.printStackTrace();
        }
    }

    @AllArgsConstructor
    class Download implements Runnable {
        long start;
        long end;
        long page;
        String fileName;

        @Override
        public void run() {
            try {
                download(start, end, page, fileName);
            } catch (Exception e) {
                e.printStackTrace();
            }
        }
    }

    @AllArgsConstructor
    static class FileInfo {
        long length;
        String fileName;
    }
}
